﻿// PasswordHasher.cs
// 
// Copyright (C) 2008-2010 Scott Wegner
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 2 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program.  If not, see <http://www.gnu.org/licenses/>.
//

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Security.Cryptography;
using System.Globalization;

[assembly: CLSCompliant(true)]
namespace PwdhashSharp.Library
{
    /// <summary>
    /// A class to create strong passwords that are always reproducible given
    /// a URI from the original domain, and the passphrase.  The algorithm
    /// uses a HMAC MD5 hash, along with a pseudo-random function used to 
    /// assure password strength, and obfuscate the process.
    /// </summary>
    public class PasswordHasher
    {
        #region Public Constructors
        /// <summary>
        /// Create a new PasswordHasher.  Initialize with the full domain that
        /// should be hashed.
        /// </summary>
        /// <param name="domain">The <see cref="System.String"/> domain component
        /// of an original URI</param>
        /// <param name="passphrase">The "master password" that is used for
        /// hashing with.</param>
        public PasswordHasher(string domain, string passphrase)
        {
            // Check our parameters
            if (string.IsNullOrEmpty(domain))
                throw new ArgumentException("Please enter a valid domain", "domain");
            if (string.IsNullOrEmpty(passphrase))
                throw new ArgumentException("Please enter a valid passphrase", "passphrase");

            // Make sure we have only a domain
            Domain = DomainExtractor.ExtractDomain(domain);
            Passphrase = passphrase;
        }
        #endregion

        #region Public Methods
        /// <summary>
        /// Create a strong password from the given URI and passphrase
        /// </summary>
        /// <returns>
        /// A <see cref="System.String"/> representing the generated password
        /// </returns>
        public string CreateHashedPassword()
        {
            // Get initial hash
            string hash = B64HmacMD5(Passphrase, Domain);

            // Apply constraints
            int size = Passphrase.Length + Constants.PasswordPrefix.Length;
            bool nonalphanumeric = new Regex("\\W").IsMatch(Passphrase);
            string result = ApplyConstraints(hash, size, nonalphanumeric);

            return result;
        }
        #endregion

        #region Protected Methods
        /// <summary>
        /// Create a base-64 encoded HMAC MD5 hash from a key and data string
        /// </summary>
        /// <param name="password">
        /// A <see cref="System.String"/> representing the inital password.
        /// This will be used as the "key" for MD5 encryption
        /// </param>
        /// <param name="realm">
        /// A <see cref="System.String"/> representing the initial password.
        /// This will be used as the "data" for MD5 encryption
        /// </param>
        /// <returns>
        /// A <see cref="System.String"/> representing the base-64 encoded hash
        /// function
        /// </returns>
        protected static string B64HmacMD5(string password, string realm)
        {
            Trace.WriteLineIf(GeneralSwitch.TraceInfo,
                string.Format(CultureInfo.CurrentCulture, "B64HmacMD5 password:\t{0}", password));
            Trace.WriteLineIf(GeneralSwitch.TraceInfo,
                string.Format(CultureInfo.CurrentCulture, "B64HmacMD5 realm:\t\t{0}", realm));

            // Put data in byte arrays to use for MD5
            byte[] key = System.Text.Encoding.ASCII.GetBytes(password);
            byte[] data = System.Text.Encoding.ASCII.GetBytes(realm);

            // Create our hash
            byte[] hashb;
            using (HMACMD5 hmac = new HMACMD5(key))
            {
                hashb = hmac.ComputeHash(data);
            }

            // Store it as a base-64 string
            string hash = Convert.ToBase64String(hashb);

            // Remove trailing "=="'s, which are added for strict adherence
            // to MD5 RFC.
            Regex reg = new Regex("=+$");
            string newhash = reg.Replace(hash, "");

            Trace.WriteLineIf(GeneralSwitch.TraceInfo,
                string.Format(CultureInfo.CurrentCulture, "B64HmacMd5 hash:\t\t{0}", newhash));

            return newhash;
        }

        /// <summary>
        /// Augment the generated password to adhere to some constraints.
        /// Specifically, it should have at least one upper-case, one lower-case
        /// and one numeral character.  Also, if specified, it should contain
        /// at least one symbol.  Otherwise, it shouldn't contain any.  Then,
        /// trim it to be the specified length, and add some pseudo-entropy to
        /// make it harder to guess.
        /// </summary>
        /// <param name="hash">
        /// A <see cref="System.String"/> representing the initial password hash
        /// </param>
        /// <param name="size">
        /// A <see cref="System.Int32"/> containing the final size of the
        /// password
        /// </param>
        /// <param name="nonAlphanumeric">
        /// A <see cref="System.Boolean"/> determining whether or not the final
        /// password should contain symbols
        /// </param>
        /// <returns>
        /// A <see cref="System.String"/> containing the final string, with
        /// constraints applied.
        /// </returns>
        protected string ApplyConstraints(string hash, int size, bool nonAlphanumeric)
        {
            if (size < 0)
            {
                throw new ArgumentException("Need a non-negative size", "size");
            }
            if (string.IsNullOrEmpty(hash))
            {
                throw new ArgumentNullException("hash");
            }

            int startingSize = size - 4; // Leave room for extra characters

            Trace.WriteLineIf(GeneralSwitch.TraceInfo,
                string.Format(CultureInfo.CurrentCulture, "ApplyConstraints startingSize:\t{0}", startingSize));
            Trace.WriteLineIf(GeneralSwitch.TraceInfo,
                string.Format(CultureInfo.CurrentCulture, "ApplyConstraints hash size:\t{0}", hash.Length));

            // Be careful on the bounds of our substring
            string result;
            if (startingSize < 0)
            {
                result = string.Empty;
                extras = new Queue<char>(hash.ToCharArray());
            }
            else if (startingSize > hash.Length)
            {
                result = string.Copy(hash);
                extras = new Queue<char>(0);
            }
            else
            {
                result = hash.Substring(0, startingSize);
                extras = new Queue<char>(hash.Substring(startingSize).ToCharArray());
            }

            Regex matchSymbol = new Regex("\\W");

            Trace.WriteLineIf(GeneralSwitch.TraceInfo,
                string.Format(CultureInfo.CurrentCulture, "ApplyConstraints result (start):\t{0}", result));
            Trace.WriteLineIf(GeneralSwitch.TraceInfo,
                string.Format(CultureInfo.CurrentCulture, "ApplyConstraints extras (start):\t{0}", new string(extras.ToArray())));
            Trace.WriteLineIf(GeneralSwitch.TraceInfo,
                string.Format(CultureInfo.CurrentCulture, "ApplyConstraints nonalphanumeric:\t{0}", nonAlphanumeric));

            // Add the extra characters
            result += (new Regex("[A-Z]").IsMatch(result) ? nextExtraChar() : nextBetween('A', 26));
            Trace.WriteIf(GeneralSwitch.TraceVerbose, string.Format(CultureInfo.CurrentCulture, "After applying [A-Z]:\t\t\t{0}", result));

            result += (new Regex("[a-z]").IsMatch(result) ? nextExtraChar() : nextBetween('a', 26));
            Trace.WriteIf(GeneralSwitch.TraceVerbose, string.Format(CultureInfo.CurrentCulture, "After applying [a-z]:\t\t\t{0}", result));

            result += (new Regex("[0-9]").IsMatch(result) ? nextExtraChar() : nextBetween('0', 10));
            Trace.WriteIf(GeneralSwitch.TraceVerbose, string.Format(CultureInfo.CurrentCulture, "After applying [0-9]:\t\t\t{0}", result));

            result += (new Regex("\\W").IsMatch(result) && nonAlphanumeric ? nextExtraChar() : '+');
            Trace.WriteIf(GeneralSwitch.TraceVerbose, string.Format(CultureInfo.CurrentCulture, "After applying \\W:\t\t\t{0}", result));

            while (matchSymbol.IsMatch(result) && !nonAlphanumeric)
            {
                Match match = matchSymbol.Match(result);
                result = matchSymbol.Replace(result, nextBetween('A', 26).ToString(), 1);
                Trace.WriteIf(GeneralSwitch.TraceVerbose, string.Format(CultureInfo.CurrentCulture, "After replacing {0}:\t\t{1}", match, result));
            }

            // Rotate the result to make it harder to guess the inserted locations
            char[] rotateArr = result.ToCharArray();
            rotateArr = rotate(rotateArr, nextExtra());
            result = new string(rotateArr);
            Trace.WriteIf(GeneralSwitch.TraceVerbose, string.Format(CultureInfo.CurrentCulture, "After rotating:\t\t{0}", result));

            // Strip out any null characters and trailing text.
            result = stripNullChar(result);

            return result;
        }
        #endregion

        #region Private Helper Functions
        /* 
		 * These functions are used in the ApplyConstraints method.  They are
		 * provided as a convenience, and to make the code more readable.  They
		 * also exist in the original pwdhash javascript code.
		 */

        /// <summary>
        /// Get the next extra character as an int if one exists-- otherwise 0
        /// </summary>
        /// <returns>The next extra character int value</returns>
        private int nextExtra()
        {
            if (extras.Count != 0)
                return Convert.ToInt32(extras.Dequeue());
            else
                return 0;
        }

        /// <summary>
        /// Get the next extra as a character
        /// </summary>
        /// <returns>The next extra character</returns>
        private char nextExtraChar()
        {
            return Convert.ToChar(nextExtra());
        }

        /// <summary>
        /// Rotate the character array a given number of times
        /// </summary>
        /// <param name="arr">The array to rotate</param>
        /// <param name="amount">Number of times to rotate</param>
        /// <returns>A new rotated array</returns>
        private static char[] rotate(char[] arr, int amount)
        {
            Trace.WriteIf(GeneralSwitch.TraceInfo, string.Format(CultureInfo.CurrentCulture, "rotate: Rotating {0} times", amount));
            Queue<char> q = new Queue<char>(arr);
            while (amount-- != 0)
                q.Enqueue(q.Dequeue());

            return q.ToArray();
        }

        /// <summary>
        /// Return a integer within the given bounds
        /// </summary>
        /// <param name="min">Lower bound</param>
        /// <param name="interval">Number of elements within bound</param>
        /// <param name="offset">An offset from the minimum</param>
        /// <returns>An integer within the bounds</returns>
        private static int between(int min, int interval, int offset)
        {
            return min + offset % interval;
        }

        /// <summary>
        /// Get the next extra character within the given bounds
        /// </summary>
        /// <param name="baseChar">Lower bound character</param>
        /// <param name="interval">Number of elements within bound</param>
        /// <returns>Next character within the bounds</returns>
        private char nextBetween(char baseChar, int interval)
        {
            return Convert.ToChar(between(Convert.ToInt32(baseChar), interval, nextExtra()));
        }

        /// <summary>
        /// If the string contains a null character, remove it, as well as any
        /// text following it
        /// </summary>
        /// <param name="str">The string</param>
        /// <returns>A new string without null characters or any text following it</returns>
        private static string stripNullChar(string str)
        {
            int pos = str.IndexOf(Convert.ToChar(0));
            if (pos == -1)
                return str;

            string subStr = str.Substring(0, pos);
            return subStr;
        }

        #endregion

        #region Data Members
        /// <summary>
        /// Domain component of a website to create a password first.  Should
        /// be pre-processed by DomainExtractor to remove extra characters.
        /// </summary>
        protected string Domain { get; set; }

        /// <summary>
        /// User's master site password.  Hashed with the domain to create a 
        /// strong password.
        /// </summary>
        protected string Passphrase { get; set; }

        /// <summary>
        /// Used by ApplyConstrains helper functions
        /// </summary>
        private Queue<char> extras;

        /// <summary>
        /// Switch to enable or disable trace messages
        /// </summary>
        internal static TraceSwitch GeneralSwitch = new TraceSwitch("General", "Entire Application");
        #endregion
    }

}
